Skip to content

feat: coerce nested dataclass fields in Relation.load - #2741

Open
tonyandrewmeyer wants to merge 10 commits into
canonical:mainfrom
tonyandrewmeyer:relation-load-coercion
Open

tonyandrewmeyer wants to merge 10 commits into
canonical:mainfrom
tonyandrewmeyer:relation-load-coercion

Conversation

@tonyandrewmeyer

@tonyandrewmeyer tonyandrewmeyer commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Relation.load passes the decoded relation data straight to the data class's __init__, so a field whose own type is a dataclass or an enum arrives as a plain dict or str, and the attribute access the charm does next fails. This PR changes that to coerce each field against its type hint before construction, recursively, for plain dataclasses only - Pydantic models and pydantic dataclasses keep doing their own coercion and validation.

  • list, set and frozenset coerce their elements; a fixed-length tuple coerces each position against its own type and stays a tuple; dict/Mapping coerce their values.
  • An Optional/Union field is coerced against its single non-None member. A union of more than one concrete type is passed through as-is, since there's no way to tell which member to coerce against.
  • Where the type hints can't be resolved at all, construction falls back to the uncoerced path rather than failing.

Split out of #2557, which is where this came from and which needs it: ops_tracing's de-vendored databag models have nested dataclasses one level deep, and its suite doesn't pass without this. It seems like it's valuable outside of that (if we end up going with using the charmlibs libraries, for example), and reviewing it separately makes it easier to review #2557 focussing specifically on what it's about.

Split out of canonical#2557, where this shipped alongside the
ops_tracing de-pydantic work. Relation.load now recursively constructs
nested dataclasses and coerces Enum field values when the target is a
non-pydantic dataclass, so callers get typed nested objects instead of
raw decoded dicts. This carries over the original implementation from
canonical#2557 unmodified; the review at REVIEW-2557.md found several holes in
it, fixed in the commits that follow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1dP9XqjiuoYz4oHjWYAQ6
….load

_coerce_field returned early for anything whose typing.get_origin wasn't
list/tuple/set/frozenset, which includes Optional[X] (a Union) and
dict[str, X] - both common shapes for nested-model fields. A charm
reading data.inner.a on an Optional[Nested] field got an AttributeError
instead, since inner stayed a plain dict.

Coerce a Union against its single non-None member (a Union of more than
one concrete type has no way to pick a target, so it still passes
through as-is), and a dict/Mapping's values against the value type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1dP9XqjiuoYz4oHjWYAQ6
…ionally

origin in (list, tuple) shared one branch, so tuple[X, ...] fields came
back as a list, and every element of a fixed-length tuple[X, Y] was
coerced against args[0], leaving Y untouched.

Split the branch: a tuple stays a tuple, a variable-length tuple[X, ...]
(args[-1] is Ellipsis) coerces every element against X, and a
fixed-length tuple coerces each position against its own type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1dP9XqjiuoYz4oHjWYAQ6
The guard used __is_pydantic_dataclass__, which only exists from pydantic
2.11. Measured across installed versions, it's absent on 2.0.3, 2.4.2,
2.6.4 and 2.10.6. ops declares no pydantic dependency - the charm's own
pin decides - and ops's test extra permits 2.10.x, so a charm pinned
there got ops's pre-coercion applied ahead of pydantic's own validators,
and any extra kwargs pydantic would have accepted silently dropped.

'__pydantic_validator__' in cls.__dict__ is what
pydantic.dataclasses.is_pydantic_dataclass itself checks for, and was
present on every version tested from 2.0.3 up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1dP9XqjiuoYz4oHjWYAQ6
… be resolved

get_type_hints(cls) resolves every field's annotation eagerly, so a
TYPE_CHECKING-only import with no runtime name raised NameError even
for relation data that never touched the affected field - a regression
against main's cls(**data) path, which didn't need the hints at all.
ops's own ruff config disables TC001/2/3, so charms following ops's own
conventions are the ones most likely to hit this.

Fall back to the un-coerced cls(**data) when hints can't be resolved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1dP9XqjiuoYz4oHjWYAQ6
…ional args

The `not args` guard meant any positional argument silently turned off
coercion entirely: relation.load(MyData, event.app) coerced, and
relation.load(MyData, event.app, something) did not, with nothing
telling the caller so.

args are matched to cls's leading dataclass fields by position, so there
is nothing to coerce them against - but the remaining fields, still
supplied from the relation data, can and should keep being coerced.
_build_dataclass now takes the positional args through and only skips
coercion for the fields they fill.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1dP9XqjiuoYz4oHjWYAQ6
The docstring still said the data "is passed to the data class's
__init__ method as keyword arguments", which after recursive coercion
holds only for pydantic targets and flat dataclasses. State which field
types are coerced, which pass through unchanged, and what happens when
positional arguments or unresolvable type hints are involved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1dP9XqjiuoYz4oHjWYAQ6
Comment thread ops/model.py Outdated
@tonyandrewmeyer
tonyandrewmeyer marked this pull request as ready for review September 10, 2026 04:53
Comment thread ops/charm.py
Comment thread ops/charm.py
Comment thread ops/charm.py
tonyandrewmeyer and others added 2 commits September 16, 2026 09:32
Three problems with _coerce_field, all from review of canonical#2741:

An Optional[X] field whose databag value is null was coerced against X,
which cannot accept None: a nested dataclass raised TypeError from the
membership test, a list raised on iteration, and an enum raised
ValueError. None now short-circuits, so `field: Nested | None` with
`null` in the databag gives None as it did before coercion existed.

A frozenset[X] field was built with a set comprehension, so it always
produced a set. The set and frozenset branches are now separate and each
produces its own type.

A str, bytes or mapping value for a sequence field was iterated
element-wise, quietly producing a list of characters or of the mapping's
keys rather than failing. Those three are now rejected with a TypeError
naming the expected type, as is a non-mapping value for a dict field,
which previously surfaced as AttributeError: 'list' object has no
attribute 'items'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_build_dataclass` chooses which fields to fill with `field.name not in
data`, which is False for every field of a string or a list, so a remote app
writing `"oops"` where a nested dataclass belongs got a confidently
default-constructed object corresponding to nothing in the databag - and
`"subscribe"`, which happens to contain `sub`, got "string indices must be
integers" from inside ops instead. A charm can't stop a remote app writing
nonsense, so this is the path that matters.

The nested-dataclass branch now rejects a non-mapping the way the sequence
and mapping branches already do, naming the class and what arrived.

A value that is already an instance of the field's class is passed through:
`Relation.load`'s keyword arguments go through the same coercion as the
databag, and they are documented as passed through to the data class, so
`load(Data, app, nested=Nested(sub=5))` must not try to build a `Nested` out
of a `Nested`.

`_juju_fields` also keys its pydantic check off `__pydantic_validator__`
rather than `__is_pydantic_dataclass__`, which is what `Relation.load`
already does and what `pydantic.dataclasses.is_pydantic_dataclass` itself
checks. `__is_pydantic_dataclass__` only exists from pydantic 2.11, so on
2.10 an aliased field came back under its field name: measured against
pydantic 2.10.6, `_juju_fields` gave `{'secret-id': 'secret_id'}` before this
and `{'secret-id': 'secret-id'}` after, matching 2.13.4 either way. ops's own
test extra is `pydantic~=2.10`, so both are in scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread ops/charm.py
Comment on lines -1683 to +1692
# Using pydantic.dataclasses.is_pydantic_dataclass() would be
# best here, but we don't want to import pydantic in ops, so
# we look more explicitly.
if getattr(cls, '__is_pydantic_dataclass__', False):
# Using pydantic.dataclasses.is_pydantic_dataclass() would be best
# here, but we don't want to import pydantic in ops, so we check
# for the attribute that function itself checks for. Note that
# '__is_pydantic_dataclass__' only exists from pydantic 2.11, so
# relying on that one misses every earlier 2.x pydantic dataclass,
# which reads the aliases back under their field names instead.
if '__pydantic_validator__' in cls.__dict__:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth landing this fix in a separate PR?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can if you prefer. It seems small enough (and I suspect non-critical but I didn't actually check what versions charms are using) to bundle in here. But it is a separate fix so I can make a little PR only for that.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's do the split.

@james-garner-canonical james-garner-canonical left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some feedback on the details. Still digesting the big picture behaviour change.

Comment thread ops/model.py
Comment on lines +1816 to +1817
- ``Literal`` fields, and any other constructed generic not listed
above, are passed through unchanged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we can do better here. For example, it seems something like Literal[1] should at least be treated like int. Since Literal only accepts pretty basic types, this is very tractable. On the other hand, if the databag was properly JSON-serialized, then you'd have the correct type (int) here anyway.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Literal[1] would get treated as int here, but that wouldn't actually do anything: _coerce_field only builds things (nested dataclasses, enums, collections) and passes any other scalar type straight through, so an int annotation is already a no-op.

Or do you mean if the databag had '1' but the type was int it should end up as 1? I'm pretty hesitant about that, it seems like it could introduce all sorts of problems, and isn't very Pythonic.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Literal[1] would get treated as int here, but that wouldn't actually do anything: _coerce_field only builds things (nested dataclasses, enums, collections) and passes any other scalar type straight through, so an int annotation is already a no-op.

True, that makes sense. So maybe that should just be the comment about Literal -- not sure though.

Or do you mean if the databag had '1' but the type was int it should end up as 1? I'm pretty hesitant about that, it seems like it could introduce all sorts of problems, and isn't very Pythonic.

I think Pydantic models do this if the model hasn't configured strict mode FWIW. I don't think we should follow suit.

Comment thread ops/model.py
Comment on lines +1805 to +1811
- ``list``, ``set``, and ``frozenset`` fields coerce each element
against the type argument.
- A variable-length ``tuple[X, ...]`` coerces every element against
``X``; a fixed-length ``tuple[X, Y, ...]`` coerces each position
against its own type, and stays a ``tuple``.
- A ``dict``/``Mapping`` field coerces its values against the value
type.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Document once as collections, listing the supported collection types. Explain fixed-length tuples as a separate follow-up bullet point, explaining what happens if there are more values than annotated positions.

Comment thread ops/model.py
Comment on lines +1812 to +1815
- An ``Optional``/``Union`` field is coerced against its single
non-``None`` member; a ``Union`` of more than one concrete type is
passed through as-is, since there is no way to tell which member to
coerce against.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we can do better for unions. For example, a list[SomeDataClass] | dict[int, SomeDataClass] can be resolved since we either get a value like [{...}] or {1: {...}}.

I'm also thinking about cases like SomeEnum | int or SomeDataClass | str -- or SomeDataClass | SomeEnum and the value is a string and SomeDataClass(val) and SomeEnum(val) would both resolve cleanly. Would trying left to right be problematic? How does Pydantic handle this -- I'd guess left to right, but I haven't checked.

I'd also be OK with drawing a clean boundary somewhere and documenting the current limitations and possible extension points, but I'm not sure the current implementation is the right place to draw that line.

Comment thread ops/model.py
Comment on lines +1819 to +1821
If the class's type hints can't be resolved at all - for example, a
``TYPE_CHECKING``-only import with no runtime name - the values are
passed through uncoerced instead of raising.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess stringified annotations may also end up here? Pydantic itself faces similar troubles https://pydantic.dev/docs/validation/latest/internals/resolving_annotations/#the-challenges-of-runtime-evaluation

Comment thread ops/model.py
Comment on lines +1823 to +1829
Any additional positional or keyword arguments will be passed through
to the data class ``__init__``. For a non-pydantic dataclass target,
positional arguments are matched to the class's leading fields by
position; those fields are passed through as given rather than
coerced, since there is no field name to coerce them against, but any
remaining fields supplied from the relation data are still coerced as
above.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think user-supplied **kwargs to this function shouldn't be coerced at all, both for symmetry with user-supplied *args and just on general principle -- the user can (and should) just supply fully-resolved arguments.

This is a bit problematic with the design here of having the user-supplied arguments be overridden by those from the databag on collision, but definitely not unresolveable -- build data starting from a plain dict, combine as {**kwargs, **data} for the Pydantic case, and pass separately as data and {k for k in kwargs if k not in data} for dataclasses.

Comment thread ops/charm.py
return value


def _build_dataclass(cls: Any, data: Mapping[str, Any], *args: Any) -> Any:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no need to accept args as variadic -- it just adds a * at the call-site.

Comment thread ops/charm.py
and args
and isinstance(value, (str, bytes, Mapping))
):
given = cast('Any', value)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

value seems to already be annotated as Any at the function scope. The isinstance narrows it to str | bytes | Mapping. Mapping[Unknown] perhaps? (Thanks, Pydantic!) -- is that why we need a cast here to be able to call type? Rather than a cast, I think we'd be better off here with something like given_type = type(value) # pyright: ignore[...].

Comment thread ops/charm.py
Comment thread ops/charm.py
raise ValueError('Unable to find class fields')


def _coerce_field(tp: Any, value: Any) -> Any:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would _coerce_field(tp: type[T], value: Any) -> T: work?

Looking further, this seems to be complicated by accepting annotation types like Optional and so on as well.

What do you think about breaking this down into a pair of helpers: one that accepts complex type annotations, resolved the inner types, and so on, and a separate function that takes only fully-resolved types?

Comment thread ops/charm.py
Comment on lines +1723 to +1724
origin = typing.get_origin(tp)
if origin is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At a glance its' really tough to tell which branches are terminal and where we fall through. Factoring into helpers might help. For example if origin is None here we could return _coerce_resolved(tp, value).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants